[JavaScript 笔记] JavaScript 异步编程

date
Apr 1, 2022
slug
[JavaScript 笔记] JavaScript 的异步模式
status
Published
tags
JavaScript
summary
type
Post

什么是同步异步

从时序来看(可参看时序图),程序运行有同步和异步两种模式,那怎么叫同步异步呢?我的总结如下:程序执行后立即返回结果(也可能是代码执行),就是同步模式;没有立即返回结果,在未来某个时刻才会返回结果,就是异步模式;各举一个例子:
同步模式:你去银行取款,叫到你的号了(开始执行了),经过一系列的手续(中间柜员在操作系统以及出款等等流程时,你是一直在柜台的),要等你取到款了离开柜台(执行结束)。
步模式:你去麦当劳吃饭,到你点餐了(开始执行了),下好单付款就可以离开了(你不需要等到取餐的,部分结束),你可以等在旁边,你也可以刷刷手机(这里还有另一个概念:阻塞和非阻塞,本文暂不讨论),几分钟之后听到叫号了,就过去取餐(整个结束)。
总结一下关键点:执行结果是否立即返回。

同步和异步的意义

我们写的代码中,大部分都是同步代码,比如同步场景:赋值、取值、运算、条件判断等等,异步场景:文件读取、网络访问、事件等等。我想了个比喻:同步代码是你问我答,异步代码是飞鸽传书。
看起来是同步代码更快,但是,性能调优时我们经常会考虑应用这个策略:同步代码改异步代码,以提升效率。那问题来了,什么时候同步,什么时候异步?谁更快?
先分析一下优劣势:
同步代码:需要执行完成并拿到结果,才进入下一步,等待时间要看逻辑复杂程度、I/O快慢等因素。优势是调用完成就可以拿到结果,劣势是如果代码比较耗时,会阻塞代码往下执行;
异步代码:完成调用执行,即可以进入下一步,结果未来某个时刻取到。优劣势和同步正好相反,优势是代码比较耗时时,不会阻塞代码的继续执行,劣势是不能在调用完成时同步拿到结果,并且需要考虑如何获取和处理未来的结果。
可以用排除法,先看看哪些需要异步,剩下的就是同步。
  • 海量数据运算、慢 I/O,并且不希望长时间阻塞代码,比如仿真模型的执行、大文件的哈希。
  • 分布式应用,希望提高硬件利用率,把阻塞代码变成异步代码,例如电商系统中的消息队列(MQ).
  • 不确定时间的结果,比如网络请求、用户事件,典型例子就是 Ajax 请求。
  • 定时任务,比如 setTimeout
以上几种就是比较常见的异步场景,其他的场景基本同步就可以解决。而谁更快的问题,并不取决于模式,而是要看具体代码的,看计算量大小、看 I/O 快慢等等。

JavaScript 中的同步异步

JavaScript 在早期时候只是当作浏览器的脚本,随着 Ajax 技术和富应用的流行,JavaScript 成为了 Web 开发 的核心组成,异步模式也应用越来越多,语言层面的支持也越来越丰富了。
还是排除法,我们这里只看JavaScript 中的几种异步模式,每一种异步模式都有自己的特点,强力推荐阅读《你不知道的JavaScript(中卷)》第二部分内容。

回调函数

Promise 之前,异步只能用回调函数实现。回调是最早使用,也是用得最多的异步模式,有些同学可能都没意识到它是异步的,问什么是异步。看示例:
function getText(url, success) {
  const xhr = new XMLHttpRequest();
  xhr.onreadystatechange = function () {
		if (this.readyState !== 4) return; 
    if (this.status == 200) {
      success(this.responseText);
    } else {
      throw new Error(this.statusText);
    }
  };
  xhr.open('GET', url, true);
  xhr.send();
}

getText('/demo/hello.txt', function success(responseText) {
  console.log(responseText);
});
这个就是最简单的基于回调函数的异步代码,一个基础的 ajax 实现,getText 调用完成时没有返回数据,数据是在回调函数 success 在未来获取。
回调函数在 web 1.0 时期是够用的,到了 web 2.0 富应用出现后,有很多的不方便之处:回调地狱、代码顺序问题、信任问题等等。

Promise

ES6 提供了 Promise微任务 支持,让异步模式使用更加方便和高效。
其实 Promise 也是一种回调,大家可以看 ES5 模拟实现,但是有个重要的区别:ES6 之后的 Promise resolve 后,会加入微任务队列,细节可以去查阅微任务相关主题。
来个 Promise 的对比示例(可以对比上面的回调函数看看):
function getTextPromise(url) {
  return new Promise(function (resolve, reject) {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function () {
      if (this.readyState !== 4) return; 
      if (this.status == 200) {
        resolve(this.responseText);
      } else {
        reject(new Error(this.statusText));
      }
    };
    xhr.open('GET', url, true);
    xhr.send();
  });
}

getTextPromise('/demo/hello.txt')
.then((responseText) => {
  console.log(responseText);
});
乍一看,感觉和回调函数差不多。简单场景下确实就是差不多的,只不过回调函数不在参数里面,而是在链式方法 then 的参数里面。但是当场景复杂之后,就能体现出 Promise 的优势。
// 多个请求顺序执行
getTextPromise('/demo/hello.txt')
.then((responseText) => {
  console.log(responseText);
	return getTextPromise('/demo/my.txt');
})
.then((responseText) => {
  console.log(responseText);
	return getTextPromise('/demo/world.txt');
})
.then((responseText) => {
  console.log(responseText);
});

// 多个请求并发
Promise.all([getTextPromise('/demo/hello.txt'),
 getTextPromise('/demo/my.txt'), getTextPromise('/demo/world.txt')])
.then(([responseText1, responseText2, responseText3]) => {
  console.log(responseText1, responseText2, responseText3);
});

// 多个请求竞争
Promise.race([getTextPromise('/demo/hello.txt'),
 getTextPromise('/demo/my.txt'), getTextPromise('/demo/world.txt')])
.then((responseText) => {
  console.log(responseText);
});
复杂场景用 Promise 会优雅和易读很多,不会有回调地狱,且代码顺序符合正常思维习惯,还有更多其他优点:不会多次调用,良好的错误处理机制等等。

生成器(Generator)

ES6 另外还新增了一个异步工具:生成器,见下面示例(摘录自 MDN):
function* generator(i) {
  yield i;
  yield i + 10;
}

const gen = generator(10);

console.log(gen.next().value);
// expected output: 10

console.log(gen.next().value);
// expected output: 20
生成器函数执行过程中可以暂停和恢复,暂停时可以拿到 yield 值,恢复时还可以传值。
生成器的特点是:
  • 可以让生成器函数的语句暂停,同时保持当前状态。
  • 可以多次暂停,这个和 Promise 不太一样,Promise 只有未完成和已完成(分成功和失败)状态。
  • 生成器函数会返回一个迭代器,通过迭代器控制代码恢复执行和传值。
生成器的优势是:保持异步代码的顺序,看起来和阻塞的同步代码一样。举一个最常见的应用场景:ID 生成器。还有很多高级用法,还是推荐阅读《你不知道的JavaScript(中卷)》。

async/await

ES7 又增加了一个异步语法:async/await,其实是个语法糖,让我们更方便地使用 Promise ,好处类似于生成器:让异步代码看起来和阻塞的同步代码一样,有正常的逻辑顺序。
function getTextPromise(url) {
  return new Promise(function (resolve, reject) {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function () {
      if (this.readyState !== 4) return; 
      if (this.status == 200) {
        resolve(this.responseText);
      } else {
        reject(new Error(this.statusText));
      }
    };
    xhr.open('GET', url, true);
    xhr.send();
  });
}

async function asyncCall() {
  console.log('calling');
  const result1 = await getTextPromise('/demo/hello.txt');
  console.log(result1);
	const result2 = await getTextPromise('/demo/world.txt');
  console.log(result2);
	console.log('calling end');
}

asyncCall();
async/await 之后,Promise 对象就无需使用 then 方法链来获取异步结果了,异步写法变成了同步写法,符合我们正常的逻辑思考顺序。

小结

异步模式的核心就是把现在和将来连接起来,处理好它们的关系。学会了上面几种异步模式,你就对 Javascript 异步编程有了基本的认识了,最后:多多实践和总结。

© XieZhichao 2022 - 2024